10.8 手动管理内存

C++内存

我们的C++程序主要包含如下三种内存:

  • 静态内存:保存局部static对象、类static数据成员以及定义在任何函数之外的变量

  • 栈内存:保存定义在函数内的非static对象

  • 自由空间/堆:存储动态分配的对象,即在程序运行时分配的对象

管理动态内存的运算符

C++语言定义了两个运算符来分配和释放动态内存:

  • new:在动态内存中为对象分配空间并返回一个指向该对象的指针

  • delete:接受一个动态对象的指针,并释放与之关联的内存

new:动态分配和初始化对象

1. 简介

在自由空间分配的内存是无名的,因此new无法为其分配的对象命名,而是返回一个指向该对象的指针:

// pi指向一个动态分配的、未初始化的无名对象
int *pi = new int;

2. 动态分配对象初始化

Tips:最好对动态分配的对象进行初始化。

默认情况下,动态分配对象是默认初始化的,这意味着内置类型和组合类型的对象的值是未定义的,而类类型对象将用默认构造函数进行初始化。

string *ps = new string;  // 类类型: 默认构造函数初始化为空string
int *pi = new int;        // 内置类型: pi指向一个未初始化的int

我们也可以在动态分配对象时进行初始化:

// 直接初始化
int *pi = new int(1024);

// 调用string的构造函数进行初始化(圆括号)
string *ps = new string(10, '9');

// 列表初始化
vector<int> *pv = new vector<int>{0, 1, 2, 3, 4, 5, 6, 7, 8, 9};

// 值初始化(圆括号)
string *ps = new string();  // 值初始化为空string
int *pi = new int();        // 值初始化为0

3. 动态分配的const对象

new分配一个const对象是合法的,返回一个指向const的指针:

// 动态分配的const对象必须进行初始化
const int *pci = new const int(1024);
// string定义了默认构造函数, 其const动态对象可以隐式初始化
const string *pcs = new const string;

4. 内存耗尽

一旦一个程序用光它所有可用的内存,那么new表达式就会失败,会抛出一个类型为bad_alloc的异常:

// 如果分配失败, 抛出std:bad_alloc
int *pi = new int;
// 如果分配失败, 返回一个空指针
int *p2 = new (nothrow) int;

delete:释放动态内存

delete表达式执行两个动作:销毁给定的指针指向的对象;释放对应的内存。

为了防止内存耗尽,我们必须在动态内存使用完毕后将其归还给系统,我们通过delete表达式来讲动态内存归还系统。delete表达式接受一个指针,指向我们想要释放的对象:

delete p;  // p必须指向一个动态分配的对象或是一个空指针

Tips:释放一块非new分配的内存,或者将相同的指针值释放多次,其行为是未定义的。

手动管理内存的缺点

使用newdelete管理内存存在三个常见问题:

  • 忘记delete内存:忘记释放的内存即我们常说的“内存泄漏”问题,程序直到真正耗尽内存时才能检测到这种错误

  • 使用已经释放的内存:==我们可以在释放内存后将指针置为nullptr,这样可以提前暴露错误==

  • 同一块内存释放两次:当有两个指针指向相同的动态分配对象时就可能发生这种错误,delete第一个指针时对象的内存就被归还给自由空间了,如果我们delete第二个指针,自由空间就可能被破坏

重载new和delete

重载这两个运算符与重载其他运算符的过程大不相同。想要真正重载newdelete的方法,首先要对new表达式和delete表达式的工作机制足够了解:

// new表达式
string *sp = new string("a value");  // 分配并初始化一个string对象
string *arr = new string[10];        // 分配10个默认初始化的string对象

当我们使用一条new表达式时,实际上执行了三步操作:

  • 第一步:new表达式调用一个名为operator new或者operator new[]的标准库函数,该函数分配一块足够大的、原始的、未命名的空间以便存储特定类型的对象(或者对象的数组)

  • 第二步:编译器运行相应的构造函数以构造这些对象,并为其传入初始值

  • 第三步:对象被分配了空间并构造完成,返回一个指向该对象的指针

delete sp;       // 销毁*sp, 然后释放sp指向的内存空间
delete [] arr;   // 销毁数组中的元素, 然后释放对应的内存空间

当我们使用一条delete表达式删除一个动态分配的对象时,实际上执行了两步操作:

  • 第一步:对sp所指的对象或者arr所指的数组中的元素执行对应的析构函数

  • 第二步:编译器调用名为operator delete或者operator delete[]的标准库函数释放内存空间

应用程序可以在全局作用域中定义operator new函数和operator delete函数,也可以把它们定义为成员函数。当编译器发现一条new表达式或者delete表达式后,将在程序中查找可供调用的operator函数:

  • 如果被分配(释放)的对象是类类型,则编译器首先在类及其基类的作用域中查找

  • 否则在全局作用域中查找,如果找到了用户自定义的版本,则使用该版本执行new或者delete表达式

  • 没找到的话,则使用标准库定义的版本

我们可以使用作用域运算符使得new表达式或delete表达式忽略定义在类中的函数,直接执行全局作用域的版本。比如::new::delete

operator new接口和operator delete接口

标准库定义了operator new函数和operator delete函数的8个重载版本。其中前4个可能抛出bad_alloc异常,后4个版本不会抛出异常:

// 这些版本可能抛出异常
void *operator new(size_t);        // 分配一个对象
void *operator new[](size_t);      // 分配一个数组
void *operator delete(void*) noexcept;   // 释放一个对象
void *operator delete[](void*) noexcept; // 释放一个数组

// 这些版本承诺不会抛出异常
void *operator new(size_t, nothrow_t&) noexcept;
void *operator new[](size_t, nothrow_t&) noexcept;
void *operator delete(void*, nothrow_t&) noexcept;
void *operator delete[](void*, nothrow_t&) noexcept;

标准库函数operator newoperator delete的名字让人容易误解。和其他operator函数不同,这两个函数并没有重载new表达式或者delete表达式。实际上我们根本无法自定义new表达式或者delete表达式的行为。一条new表达式的执行过程总是先调用operator new函数以获取内存空间,然后在得到的内存空间中构造对象。与之相反,一条delete表达式的执行过程总是先销毁对象,然后调用operator delete函数释放对象所占空间。

Tips:我们提供新的operator newoperator delete函数的目的在于改变内存分配的方式,无论如何我们都不能改变new运算符和delete运算符的基本含义。

malloc函数和free函数

malloc函数接受一个表示待分配字节数的size_t,返回指向分配空间的指针或者返回0以表示分配失败。free函数接受一个void*,它是malloc返回的指针的副本,free将相关内存返回给系统。调用free(0)没有任何意义。

下面给出了编写operator newoperator delete的简单方式:

void *operator new(size_t size) {
    if (void *mem = malloc(size))
        return mem;
    else
        throw bad_alloc();
}
void operator delete(void *mem) noexcept { free(mem); }

定位new表达式

C++早期版本中,allocator类还不是标准库一部分。应用程序如果想把内存分配和初始化分离开的话,需要调用operator newoperator delete。这两个函数的行为与allocatorallocate成员和deallocate成员非常类似,它们负责分配或释放内存空间,但是不会构造或销毁对象

allocator不同的是,对于operator new分配的内存空间,我们不能使用construct函数构造对象。相反我们应该用new的定位new形式构造对象。

new (place_address) type
new (place_address) type (initializers)
new (place_address) type [size]
new (place_address) type [size] { braced initializer list }

其中place_address必须是一个指针,同时在initializers中提供一个(可能为空)的以逗号值分割的初始值列表,该初始值列表用于构造新分配的对象。当仅通过一个地址值调用时,定位new使用operator new(size_t, void*),这是以一个我们无法自定义的operator new版本,它只是简单地返回指针实参,然后由new表达式负责在指定的地址初始化对象以完成整个工作。

Tips:当只传入一个指针类型的实参时,定位new表达式构造对象但是不分配内存,它允许我们在一个特定的、预先分配的内存地址上构造对象。

尽管定位newallocatorconstruct成员非常相似,但是有一个重要的区别:我们传给construct的指针必须指向同一个allocator对象分配的空间,但是传给定位new的指针无须指向operator new分配的内存,甚至不需要指向动态内存。

显式的析构函数调用

就像定位new与使用allocate类似一样,对析构函数的显式调用也与使用destroy很类似。我们既可以通过对象调用析构函数,也可以通过对象的指针或者引用调用析构函数,这与调用其他成员函数没什么区别:

string *sp = new string("a value");   // 分配并初始化一个string对象
sp->~string();

和调用destroy类似,调用析构函数可以清除给定的对象但是不会释放该对象所在的空间。如果需要的话,我们可以重新使用该空间。

Tips:调用析构函数会销毁对象,但是不会释放内存。

动态数组

1. 简介

Tips:当一个应用需要可变数量的对象时,我们总是采用更简单、更快速且更安全的方法——使用vector或者标准库其他容器。

newdelete运算符一次分配/释放一个对象,但某些应用需要一次为很多对象分配内存的功能。为了支持这种需求,C++标准库提供了两种一次分配一个对象数组的方法:

  • new直接分配一个对象数组

  • allocator类将分配和初始化分离

2. new和数组

Tips:由于分配的内存并不是一个数组类型,因此不能对动态数组调用begin()end()函数,也不能使用范围for语句来处理动态数组中的元素。

new分配要求数量的对象并在分配成功后返回第一个对象的指针:

// 10个未初始化的int
int *pia = new int[10];
// 10值初始化为0的int
int *pia2 = new int[10]();
// 10个int分别用列表中对应的初始化器初始化
int *pia3 = new int[10]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9};

// 10个空string
string *psa = new string[10];
// 10个空string
string *psa2 = new string[10]();
// 前4个string用给定的初始化器初始化,剩余的进行值初始化
string *psa3 = new string[10]{"a", "an", "the", string(3, 'x')};

3. 动态分配空数组是合法的

当我们用new分配一个大小为0的数组时,new返回一个合法的非空指针,我们可以像使用尾后指针一样去使用这个指针。可以用这个指针进行比较操作,可以从此指针加上/减去0,可以从此指针减去自身等于0,但是不能解引用该指针(因为它不指向任何元素)。

size_t n = 0
int *pi = new int[n];  // 正确: 但是cp不能解引用

// 动态数组长度为0时循环中条件为fasle, 不会执行循环体
for (int* q = p; q != p + n; ++q) {
    // 处理数组
}

4. 释放动态数组

// pa必须指向一个动态分配的数组或者为空
// 先销毁pa指向数组中的元素, 再释放对应的内存
delete [] pa;

5. 智能指针与动态数组

标准库提供了一个可以管理new分配数组的unique_ptr版本,对应的操作如下:

Tips:指向动态数组的unique_ptr不支持成员访问运算符(点和箭头运算符),其他unique_ptr操作不变。

操作 含义
unique_ptr<T[]> u u可以指向一个动态分配的数组,数组元素类型为T
unique_ptr<T[]> u(p) u可以指向内置指针p所指向的动态分配的数组
u[i] 返回u拥有的数组中位置i处的对象
// up指向10个未初始化的int数组, 当up销毁它管理的指针时会自动使用delete[]
unique_ptr<int[]> up(new int[10]);

// 使用下标运算符来访问数组元素
for (size_t i = 0; i != 10; ++i) {
    up[i] = i;
}

shared_ptr不支持管理动态数组,强行管理的话必须提供自己定义的删除器:

// 定义一个shared_ptr管理int数组, 传递一个lambda表达式作为删除器
shared_ptr<int> sp(new int[10], [](int *p) { delete[] p; });

// shared_ptr未定义下标运算符, 并且不支持指针的算数运算
for (size_t i = 0; i != 10; ++i) {
    *(sp.get() + i) = i;
}

// 使用lambda释放数组
sp.reset();

allocator类

1. 分配大块内存时拆分内存分配与对象构造

new把内存分配和对象构造组合在了一起,delete也将对象析构和内存释放组合在了一起。我们分配单个对象时通常希望将内存分配和对象构造分离开,因为这种情况下我们几乎肯定知道对象应该有什么值。但是当分配一大块内存时,我们希望将内存分配和对象构造分离开:这意味着我们可以分配大块内存,但只有在真正需要时才真正执行对象创建操作(付出一定开销)。

/*
 * 分配一大块内存时, 将内存分配和对象构造组合在一起可能导致不必要的浪费:
 * 1) 我们可能创建了一些永远用不到的对象
 * 2) 初始化后立即赋予新值可能导致每个被使用的元素都被赋值了两次: 一次是默认初始化; 一次是赋值
 *
 * 更重要的是: 没有默认构造函数的类就无法支持动态分配数组
 */

// 构造n个空string
string *const p = new string[n];
string s;
// q指向第一个string
string *q = p;
// 根据输入对空string赋予新值
while (cin >> s && q != p + n) {
    *q++ = s;
}
// 记录赋值了多少个string
const size_t size = q - p;
// 释放数组
delete[] p;

2. allocator类

标准库allocator类定义在头文件memory中,它帮助我们将内存分配与对象构造分隔开。它提供一种类型感知的内存分配方法,它分配的内存是原始的、未构造的。

操作 含义 备注
allocator<T> a 定义一个allocator对象,可以为类型T的对象分配内存
a.allocate(n) 分配一段原始的、未构造的内存,保存n个类型为T的对象
a.deallocate(p, n) 释放从T*指针p中地址开始的内存,这块内存保存了n个类型为T的对象 p必须是先前由allocate返回的指针,n必须是p创建时所要求的大小,在调用deallocate之前用户必须对每个在这块内存中创建的对象调用destory
a.construct(p, args) args被传递给类型为T的构造函数,用于在p指向的内存中构造一个对象
a.destory(p) p指向的对象执行析构函数
// 可以分配string的allocator对象
allocator<string> alloc;
// 分配n个未初始化的string
auto const p = alloc.allocate(n);
// q指向最后构造的元素之后的位置
auto q = p;
alloc.construct(q++);           // *q为空字符串
alloc.construct(q++, 10, 'c');  // *q为cccccccccc
alloc.construct(q++, "hi");     // *q为hi!

// 使用完对象后必须对每个构造的元素调用destory来销毁它们
while (q != p)
    alloc.destory(q--);
// 释放内存
alloc.deallocate(p, n);

标准库为allocator类提供了两个伴随算法,可以在未初始化内存中创建对象:

操作 含义 备注
uninitialized_copy(b, e, b2) 从迭代器be指定的输入范围中拷贝元素到迭代器b2指定的未构造的原始内存中 b2指向的内存必足够大以容纳输入序列中元素的拷贝
uninitialized_copy_n(b, n, b2) 从迭代器b指向的元素开始,拷贝n个元素到b2开始的内存中
uninitialized_fill(b, e, t) 在迭代器be指定的原始内存范围中创建对象,对象的值均为t的拷贝
uninitialized_fill_n(b, n, t) 在迭代器b指向的内存地址开始创建n的对象 b必须指向足够大的未构造的原始内存,能够容纳给定数量的对象
/*
 * 例子: 假定有一个vector<int>, 我们希望将其拷贝到一块比它所占空间大一倍的动态内存中, 再对后一半空间进行给定值填充
 */

// 分配比vi中元素所占用空间大一倍的动态内存
auto p = alloc.allocate(vi.size() * 2);
// 通过拷贝vi中的元素来构造从p开始的元素
auto q = uninitialized_copy(vi.begin(), vi.end(), p);
// 将剩余元素初始化为42
uninitialized_fill_n(q, vi.size(), 42);

管理动态资源的类

通常管理类外资源的类需要通过析构函数来释放对象所分配的资源,根据“三/五原则”它也必须自定义拷贝构造函数和拷贝赋值运算符(delete拷贝构造函数和拷贝赋值运算符也算自定义的一种)。

对于管理类外资源的类,根据如何拷贝指针成员我们可以大致分为如下三类:

  • 既不像值也不像指针的类:IO类型和unique_ptr这种不允许拷贝和赋值的类

  • 行为像值的类:标准库容器和string

  • 行为像指针的类:shared_ptr

1. 行为像值的类

为了提供类值的行为,对于类管理的资源,每个对象都应该有自己的一份拷贝。以管理string资源的类HasPtr的类而言:

  • 拷贝构造函数:完成string的拷贝而不是拷贝指针

  • 析构函数:释放string对象

  • 拷贝赋值运算符:释放对象当前的string,并从右侧运算对象拷贝string

class HasPtr {
 public:
    // 构造函数: 分配string动态内存
    explicit HasPtr(const std::string &s) : ps_(new std::string(s)) { }
    // 拷贝构造函数
    HasPtr(const HasPtr &p) : ps_(new std::string(*p.ps_)) { }
    // 拷贝赋值运算符
    HasPtr& operator=(const HasPtr &);
    // 析构函数: 释放构造函数中分配的动态内存
    ~HasPtr() { delete ps_; }
    // 类自定义的swap成员函数
    friend void swap(HasPtr&, HasPtr&);

 private:
    std::string *ps_;
};

// 拷贝赋值运算符:
// 1) 组合了析构函数和拷贝构造函数: 先销毁左侧运算对象资源, 然后从右侧运算对象拷贝数据
// 2) 自赋值安全: 如果将一个对象赋予它自身, 赋值运算符必须能正确工作
// 3) 异常安全: 当异常发生时能将左侧运算对象置于一个有意义的状态
HasPtr& HasPtr::operator=(const HasPtr &rhs) {
    auto newp = new std::string(*rhs.ps_);  // 拷贝底层string
    delete ps_;                             // 释放本对象的旧内存
    ps_ = newp;                             // 从右侧运算对象拷贝数据到本对象
    return *this;
}

2. 行为像指针的类

令一个类展现类似指针的行为的最好方法是使用shared_ptr来管理类中的资源,拷贝(或赋值)一个shared_ptr会拷贝(或赋值)shared_ptr所指向的指针。shared_ptr类会自己记录有多少用户共享它所指向的对象,当没有用户使用对象时,shared_ptr类负责释放资源。

3. swap交换操作

Tips:管理动态资源的类通常除了自定义拷贝控制成员外,还需要定义一个名为swap的函数。如果一个类定义了自己的swap成员函数,那么算法将使用类自定义版本,否则算法将使用标准库定义的swap

// 交换指针而非string数据, 提高性能
inline void swap(HasPtr &lhs, HasPtr &rhs) {
    std::swap(lhs.ps_, rhs.ps_);
}

定义了swap的类通常用swap来定义它们的“拷贝并交换赋值运算符”,这些运算符使用了一种名为拷贝并交换copy and swap的技术,将左侧运算对象与右侧运算对象的一个副本进行交换:

Tips:

  • 这种技术天生是自赋值安全且异常安全的,一方面它通过在改变左侧运算对象之前拷贝右侧运算对象保证了自赋值的安全性,另一方面代码唯一可能抛出异常的是拷贝构造函数中的new表达式,如果真的抛出异常也是在我们改变左侧运算对象之前发生

  • 由于接受的参数并不是一个引用,因此该参数需要进行拷贝初始化,既有可能调用拷贝构造函数(左值)也有可能调用移动构造函数(右值)

  • 当类定义了移动构造函数时,拷贝并交换赋值运算符也会为该类实现一个移动赋值运算符

// 拷贝并交换赋值运算符既是移动赋值运算符也是拷贝赋值运算符:
// 1) 参数并不是一个引用: 调用拷贝/移动构造函数以值传递传入一个右侧运算对象的副本
// 2) 交换左侧运算对象与右侧运算对象的副本
HasPtr& HasPtr::operator=(HasPtr rhs) {
    swap(*this, rhs);  // rhs现在指向本对象曾经使用过的内存
    return *this;      // rhs销毁, 从而delete了rhs中的指针
}

编码规范:在资源管理类中小心copying行为

Effective C++:Think carefully about copying behavior in resource-managing classes.

  • 复制RAII对象必须一并复制它所管理的资源,所以资源的copying行为决定了RAII对象的copying行为。

  • 普通而常见的RAII类的copying行为是:抑制copying、施行引用计数法(reference counting)。不过其他行为也都可能被实现。

考虑我们前面构造的Mutex资源管理类Lock,如果Lock对象被复制时可能出现非预期的效果:

Mutex m;

Lock m1(&m);  // 锁定m
Lock m2(m1);  // 将m1复制到m2, 这会发生什么?

每一个RAII类作者都要面对这个问题:当一个RAII对象被复制时会发生什么?

大多数你会选择如下两种做法之一。

1. 禁止拷贝

许多时候允许RAII对象被复制并不合理。我们可以将其拷贝操作声明为private(C++11中设置为delete)。

class Lock : private Uncopyable {  // 禁止拷贝
 public:
    ...
};

2. 对底层资源使用“引用计数法”

这种情况下复制RAII对象应该将资源的“被引用数”递增。通常只需要包含一个std::shared_ptr成员变量RAII类就可以实现reference-counting copying行为。

std::shared_ptr的默认行为时“当引用计数为0时删除所指的对象”,这并不是Lock类想要的行为。幸运的是std::shared_ptr允许指定所谓的删除器(deleter)。

class Lock {
 public:
    explicit Lock(Mutex* pm) : mutex_ptr_(pm, unlock) {
        lock(mutex_ptr_.get());
    }

 private:
    std::shared_ptr<Mutex> mutex_ptr_;
};

注意Lock类中不再声明析构函数。

编码规范: 在资源管理类中提供对原始资源的访问

Effective C++:Provide access to raw resources in resource-managing classes.

  • APIs往往要求访问原始资源(raw resources),所以每一个RAII类应该提供一个“取得其管理之资源”的办法。

  • 对原始资源的访问可能经由显式转换或隐式转换。一般而言显式转换比较安全,但隐式转换对客户比较方便。

由于许多API要求传入原始指针,因此我们在RAII类中还是必须提供原始资源的访问。

// C API
FontHandle getFont();
void releaseFont(FontHandle fh);

// RAII class: 管理动态资源 FontHandle
class Font {
 public:
    explicit Font(FontHandle fh) : f(fh) {}
    ~Font() { releaseFont(f); }
 private:
    FontHandle f;
};

假设有大量与FontHandle相关的C API,那么将Font对象转换为FontHandle会是比较频繁的需求。我们有显式转换函数和隐式转换函数两种方法。

1. 显式转换函数

class Font {
 public:
    ...
    FontHandle get() const { return f; }  // 显式转换函数
    ...
};

2. 隐式转换函数

隐式转换函数可以让客户调用C API时更加轻松且自然:

class Font {
 public:
    ...
    operator FontHandle() const  // 显式转换函数
    { return f; }
    ...
};